MCP工具系统集成:Agent能力扩展
在Claude Code里用MCP时,你是使用者;这一节你是宿主——让自己的AI产品(ai-chat-ui)自己去连接MCP server,把工具生态装到自己的Agent上,从"纯聊天"升级到"能行动的Agent"。
使用者 vs 宿主:这节到底在做什么
写一个天气查询MCP server挂到Claude Code里,那时的位置是使用者:站在AI编程工具一侧,让编程工具变得更强。
这节角色对调,你是宿主:ai-chat-ui的网关进程自己扮演MCP Client,去连接别人发布的MCP server,把它们的工具接进自己的Agent。这是两件完全不同的工作:
| 维度 | 使用者 | 宿主 |
|---|---|---|
| 谁连接MCP server | Claude Code | 你的产品(网关进程) |
| 工具发现 | 客户端自动完成 | 你要实现 listTools 动态发现并注册 |
| transport | 配置文件里声明一行 | 你要管理 stdio/HTTP 连接的生命周期 |
| 鉴权 | 客户端代管 | 你要给每个连接注入凭证、做权限边界 |
| 出错怎么办 | Claude Code 兜底 | 你要设计超时、重试、降级 |
宿主化的收益很直接:MCP生态里已有数千个现成server(搜索、数据库、浏览器操作、设计工具),接进来就等于给你的Agent一次性扩充整个工具生态,不用每个外部API都手写一个工具。Claude Code能做到的,你的产品也能做到。国内平台也在跟进——高德、百度都发布了各自的MCP server,生态可用性越来越高。
两个方向:接入别人的,还是暴露自己的
宿主化包含两个相反的方向,本节两个都做:
- 对内接入:自己的进程扮演MCP Client,连接外部server。支持stdio(npx拉起命令行server)和HTTP(直连远程端点)两种形态——上文两种transport对应的就是它
- 对外暴露:自己提供一个MCP server,把已有的工具映射出去,供其他模型/AI工具调用——本节后半段会把16章的get_weather工具暴露成一个MCP server
有同学会问:对外暴露和暴露一个HTTP接口有什么区别?区别在发现协议。MCP客户端每次连接都能通过tools/list拉取server当前的完整工具清单(名称、描述、参数schema),server更新了工具,客户端下一次调用自动可见;模型拿到清单后自己决策调用哪个工具、必须传哪些参数。HTTP接口没有这层"自我描述"——参数怎么传、什么时候调用哪个接口,都得靠人预先写死在代码或文档里。这就是MCP"与其叫工具,不如叫通信规范"的含义:按规范实现,任何模型都能即插即用。
从聊天到行动:为什么需要工具集成
ai-chat-ui已经实现了完整的聊天界面,但Agent只能"说话":
// 只能聊天的Agent
用户:帮我查下北京今天的天气
Agent:抱歉,我无法实时获取天气信息。
typescript
工具集成后的Agent可以"行动":
// 能调用工具的Agent
用户:帮我查下北京今天的天气
Agent:[调用 weather__get_weather 工具] → 北京今天晴,气温22°C,湿度65%。
typescript
工具扩展Agent的边界:从"回答问题"到"完成任务"——查天气、搜索网络、查数据库、调用API、操作文件。
工具的统一抽象
工具在系统里有一层统一抽象——定义(给模型看的名称/描述/参数schema)加执行(拿到参数返回结果):
export interface ToolExecutor {
definition: ToolDefinition // name + description + parameters
execute(params: Record<string, unknown>): Promise<ToolResult>
}
export interface ToolResult {
success: boolean
data?: unknown
error?: string
}
typescript
这节要补的是工具从哪来:此前工具是手写的(在execute里直接fetch外部API),现在换成"从MCP server动态发现"——这正是宿主的核心职责。
MCPToolAdapter:把外部工具包装成ToolExecutor
连接:两种transport
MCP server有两种部署形态,对应两种连接方式。stdio适合本地子进程(命令行工具、本地脚本),Streamable HTTP适合远程服务(团队共享、云端部署)。早期的HTTP+SSE transport已在2025年被规范废弃,新项目统一用Streamable HTTP:
// MCPToolAdapter:通用宿主适配器(支持stdio与HTTP两种transport)
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
export interface McpServerConfig {
id: string // 命名空间前缀,如 'weather'
command?: string // stdio方式:启动命令
args?: string[]
env?: Record<string, string> // stdio子进程环境变量
url?: string // HTTP方式:Streamable HTTP端点
headers?: Record<string, string> // HTTP鉴权头
}
// listTools()返回的工具形状(SDK简化)
interface McpTool {
name: string
description?: string
inputSchema: Record<string, unknown>
}
export class MCPToolAdapter {
private client: Client | null = null
async connect(config: McpServerConfig): Promise<void> {
// 有url走HTTP,否则走stdio子进程
const transport = config.url
? new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: { headers: config.headers },
})
: new StdioClientTransport({
command: config.command!,
args: config.args ?? [],
env: { ...process.env, ...config.env } as Record<string, string>,
})
this.client = new Client({ name: 'ai-chat-ui', version: '1.0.0' })
await this.client.connect(transport)
}
}
typescript
发现:client.listTools()动态注册
连接后不用手工声明有哪些工具,调一次 listTools(),server会把自己的工具清单(名称、描述、参数schema)全量推送过来。为每个工具生成一个ToolExecutor,批量注册进ToolRegistry:
// MCPToolAdapter(续)
export class MCPToolAdapter {
// ...connect同上
async loadTools(namespace: string): Promise<ToolExecutor[]> {
if (!this.client) throw new Error('MCP not connected')
// 动态发现:拉取server声明的全部工具
const { tools } = await this.client.listTools()
return tools.map(tool => this.wrapTool(namespace, tool))
}
private wrapTool(namespace: string, tool: McpTool): ToolExecutor {
// 加命名空间前缀,防止多个server出现同名工具
const fullName = `${namespace}__${tool.name}`
return {
definition: {
name: fullName,
description: tool.description ?? '',
parameters: tool.inputSchema as ToolDefinition['parameters'],
},
execute: async (params) => {
// 调用时还原成server认识的原始名
const result = await this.client!.callTool({
name: tool.name,
arguments: params,
})
return {
success: !result.isError,
data: result.content,
error: result.isError ? 'Tool execution failed' : undefined,
}
},
}
}
async close(): Promise<void> {
await this.client?.close()
this.client = null
}
}
typescript
为什么用 weather__get_weather 这种前缀:接三个server时,"search"和"database"都可能有query工具。命名空间把冲突消解在注册阶段,模型看到的工具名永远唯一。
实战:把Context7文档工具接进docs-agent
ai-chat-ui的宿主化落地选了一个真实需求:docs-agent(组件库AI助手)回答组件用法问题时,需要查外部库的最新文档——这正是Context7这个MCP server提供的能力。代码在 packages/dev-server/src/mcp/,两个文件各司其职。
先交代一个框架现状:@mastra/core 1.60还没有内置MCP Client,所以宿主侧直接用官方 @modelcontextprotocol/sdk 自建——这反而让"宿主到底做了哪些事"全部摊在明面上。
实操:从API Key到AI完成集成
整个接入过程大部分工作交给AI,人要做的是拿到凭证、给对上下文:
- 拿API Key:打开context7.com,用GitHub登录,进入用户设置创建API Key(命名随意,如
dev-test),复制生成的key(ctx7sk-前缀) - 抄官方配置片段:Context7官网每个工具下方都有现成的集成配置(JSON片段),和Claude Code、Cursor等工具看到的集成方式完全一致——这就是MCP标准化的好处:一份配置,所有客户端通用
- 交给AI:把配置片段连同"把Context7集成到项目中,方便调用最新技术文档"的指令发给AI,并在
.env里配好CONTEXT7_API_KEY=ctx7sk-xxx
两个真实踩坑值得记:
- key占位符失效:AI首次生成的配置里key是个写死的占位符,一直不生效——修复方式就是上面
loadContext7Tools从env读取的形态,密钥只进.env不进代码 - AI自己判断挂载范围:发出集成指令后,AI分析Context7的定位是"技术文档查询",主动建议只挂到docs-agent而不是全部agent——与16-02"工具跟着agent职责走"的原则一致,好工具也不该全局撒
集成完成后在Studio里验收:new chat开一个对话,问一个外部库的问题(如Vue 3.5的某个新API怎么用),消息面板上能看到context7__resolve-library-id → context7__get-library-docs的完整MCP工具调用链,答案带最新文档依据。注意它只出现在组件库助手(docs-agent)的对话里,chat-agent没有这个工具——挂载范围可控。
连接与发现:createMcpTools
// packages/dev-server/src/mcp/mcp-tools.ts(简化)
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { createTool } from '@mastra/core/tools'
export interface McpStdioServerConfig {
name: string // server标识,作为工具名命名空间前缀,如 'context7'
command: string // 启动命令,如 'npx'
args?: string[]
timeoutMs?: number // 连接+工具发现总预算,默认60s(首次npx下载包较慢)
}
export async function createMcpTools(
config: McpStdioServerConfig,
): Promise<Record<string, unknown>> {
const client = new Client({ name: `ai-chat-ui-${config.name}`, version: '1.0.0' })
const transport = new StdioClientTransport({ command: config.command, args: config.args })
try {
// 连接 + 动态发现,整体套60s超时预算
const tools = await withTimeout(
(async () => {
await client.connect(transport)
return (await client.listTools()).tools
})(),
config.timeoutMs ?? 60_000,
`MCP ${config.name} 连接/工具发现`,
)
return Object.fromEntries(
tools.map((tool) => [
mcpToolName(config.name, tool.name), // 'context7__resolve-library-id'
createTool({
id: mcpToolName(config.name, tool.name),
description: tool.description ?? `MCP server ${config.name} 的 ${tool.name} 工具`,
inputSchema: jsonSchemaPassthrough(tool.inputSchema),
execute: async (input) => {
const result = (await client.callTool({
name: tool.name, // 调用时还原成server认识的原始名
arguments: input,
})) as { content?: Array<{ type: string; text?: string }>; isError?: boolean }
const text = (result.content ?? [])
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
.map((b) => b.text).join('\n\n')
if (result.isError) throw new Error(text || `MCP 工具 ${tool.name} 调用失败`)
return { text }
},
}),
]),
)
} catch (err) {
await client.close().catch(() => {}) // 半连接状态要关掉子进程再降级
console.warn(`[mcp] ${config.name} 工具发现失败,本进程降级为不挂载:${err}`)
return {} // 降级为空工具集,不阻塞启动
}
}
typescript
三个工程决策值得记住:
- 超时预算给足60秒:stdio方式首次要用npx下载server包,网络慢是常态;但必须要有上限,
Promise.race让连接和发现整体受控。 - 失败降级为空工具集:MCP挂了不应该拖垮整个服务。返回空对象后服务照常启动,只是docs-agent少了外部文档能力——降级路径打warn日志留排查线索。
- schema透传而不是本地重建:远端server自带的JSON Schema通过Standard Schema接口原样透传给Mastra,真正的参数校验发生在MCP server端,宿主不做二次校验。
装配:环境变量门控,两条线共用
Context7需要API Key,装配逻辑用环境变量门控——MASTRA_MODEL(启用mastra线)和 CONTEXT7_API_KEY 同时存在才连接,任一缺失直接返回空工具集:
// packages/dev-server/src/mcp/context7.ts
export async function loadContext7Tools(
env: DevServerEnv,
): Promise<Record<string, unknown>> {
if (!env.mastra || !env.context7ApiKey) return {}
return createMcpTools({
name: 'context7',
command: process.platform === 'win32' ? 'npx.cmd' : 'npx', // Windows的npx是.cmd
args: ['-y', '@upstash/context7-mcp', '--api-key', env.context7ApiKey],
})
}
typescript
调用方有两条线,共用这一个装配函数——网关线(app.ts)和Studio线(mastra/index.ts):
// packages/dev-server/src/app.ts(网关线)
const definitions = env.mastra
? buildAgentDefinitions(env.mastra, await loadContext7Tools(env))
: []
// packages/dev-server/src/agents/index.ts
export function buildAgentDefinitions(
env: MastraEnv,
context7Tools: Record<string, unknown> = {},
): MastraAgentDefinition[] {
return [
chatAgentDefinition(env),
docsAgentDefinition(env, context7Tools), // MCP工具只挂docs-agent
orchestratorAgentDefinition(env),
]
}
typescript
注意工具只注入docs-agent,不是全agent共享一个大注册表。工具跟着agent的职责走:docs-agent的职责是回答组件和文档问题,它的工具面就是本地检索(search_docs)加外部文档(context7)——工具面越小,误调率越低,权限边界也越清晰。
前端零改动
Agent调用MCP工具时,服务端发出的仍然是 tool_call / tool_result 两种StreamChunk,前端ToolCallPanel照常渲染——用户看到"AI正在查外部文档→结果"的完整过程。工具来源是本地的还是MCP的,前端完全无感知,这正是协议统一的价值。
对比一下角度差异:写MCP server时关心的是"怎么把一个API包成标准工具";这一节关心的是"怎么把任意MCP server安全地接到自己的Agent上"。前者做一次,后者让你的产品获得持续扩充工具的能力。
反向输出:把自家工具暴露成MCP server
对内接入做完了,反过来——dev-server里已经有一批好用的工具(get_weather、search_docs……),把它们暴露成MCP server,其他AI工具(Claude Code、Cursor、别人的agent应用)就能直接调用。这个方向Mastra有现成支持:@mastra/mcp包的MCPServer类,几行就是一个server:
// packages/dev-server/src/mcp/weather-server.ts(真实代码)
import { MCPServer } from '@mastra/mcp'
import { getWeatherTool } from '../tools/get-weather'
export const weatherMcpServer = new MCPServer({
id: 'weather-mcp-server',
name: 'ai-chat-ui Weather Server',
version: '1.0.0',
description: 'ai-chat-ui dev-server 的工具宿主:get_weather(wttr.in 实时天气)',
tools: { get_weather: getWeatherTool }, // 直接复用已有工具,零改写
})
typescript
关键点:工具直接复用,不做适配层——getWeatherTool就是16章挂给chat-agent的同一个工具对象,MCP server只是给它加了一层协议外壳。这和context7方向恰好相反:context7是我们连别人,weather是别人连我们。
两种挂载形态
形态一:注册进Mastra实例。mastra/index.ts里mcpServers: { [WEATHER_MCP_SERVER_ID]: weatherMcpServer }一行注册。注意一个版本现状:mastra CLI 1.26的dev server尚不自动把mcpServers暴露成HTTP端点(它的/mcp/v0/*是Registry目录API),所以这个注册是"注册先行"——CLI升级后自动获得HTTP能力。
形态二:stdio独立入口。这是当下真正可用的形态,真机验证过的Claude Code项目级.mcp.json配置:
{
"mcpServers": {
"weather": {
"command": "/bin/zsh",
"args": ["-lc", "cd <仓库绝对路径>/packages/dev-server && corepack pnpm exec tsx src/mcp/weather-stdio.ts"]
}
}
}
json
weather-stdio.ts只有一行核心逻辑:weatherMcpServer.startStdio()——MCP客户端以command拉起这个进程,工具经stdin/stdout的JSON-RPC提供。两个细节:zsh -lc是为了保证corepack进PATH;dev-server是私有包不发npm,所以不走官方"bin/npx发布形态"(要发布的server才需要走npm包路线)。
用MCP Inspector验收
配置完成后用MCP Inspector连接这个server验证。Inspector支持三种连接方式:HTTP(一次性请求响应)、SSE(流式事件响应)、命令行(本质也是发起一次HTTP请求)——对应MCP协议的几种transport。连上后能看到工具清单,直接调用测试:输入"上海"(中文城市名也能识别),返回实时天气。验收通过,这个server就可以写进任何MCP客户端的配置里了。
工具鉴权与安全
作为宿主,你要对第三方server的调用安全负全责(使用者模式下这是Claude Code的事)。
API Key鉴权
// 鉴权包装:给任意工具注入密钥
export function createAuthenticatedTool(
tool: ToolExecutor,
apiKey: string
): ToolExecutor {
return {
definition: tool.definition,
execute: async (params) => {
if (!apiKey) {
return { success: false, error: 'Missing API key' }
}
// 在工具调用中注入密钥
return tool.execute({ ...params, apiKey })
},
}
}
typescript
环境变量配置
// 密钥只从环境变量来,不进代码不进配置文件
const apiKey = process.env.WEATHER_API_KEY
const toolRegistry = new ToolRegistry()
if (apiKey) {
toolRegistry.register(
createAuthenticatedTool(weatherTool, apiKey)
)
}
typescript
工具执行安全限制
外部server的响应时间、数据大小都不可控,必须在宿主侧设卡:
// 安全执行器:超时与响应大小双限制
export class SafeToolExecutor {
private maxExecutionTime = 10000 // 10秒超时
private maxResponseSize = 1024 * 1024 // 1MB限制
async execute(
tool: ToolExecutor,
params: Record<string, unknown>
): Promise<ToolResult> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), this.maxExecutionTime)
try {
const result = await tool.execute({
...params,
signal: controller.signal,
})
// 检查响应大小
const json = JSON.stringify(result.data)
if (json.length > this.maxResponseSize) {
return {
success: false,
error: 'Response too large',
}
}
return result
} catch (error) {
if (controller.signal.aborted) {
return { success: false, error: 'Tool execution timeout' }
}
throw error
} finally {
clearTimeout(timeout)
}
}
}
typescript
错误处理与降级
重试机制
// 重试执行器:指数退避
export class RetryExecutor {
async execute(
tool: ToolExecutor,
params: Record<string, unknown>,
maxRetries = 2
): Promise<ToolResult> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await tool.execute(params)
} catch (error) {
if (attempt === maxRetries) {
return {
success: false,
error: `Max retries exceeded: ${String(error)}`,
}
}
// 指数退避
await this.sleep(1000 * Math.pow(2, attempt))
}
}
return { success: false, error: 'Unknown error' }
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
typescript
降级策略
// 降级链:主工具失败依次尝试备用
export class FallbackChain {
constructor(private tools: ToolExecutor[]) {}
async execute(params: Record<string, unknown>): Promise<ToolResult> {
for (const tool of this.tools) {
try {
const result = await tool.execute(params)
if (result.success) return result
} catch (error) {
console.warn(`Tool ${tool.definition.name} failed, trying next`)
}
}
return {
success: false,
error: 'All tools in chain failed',
}
}
}
// 使用:MCP搜索server失败时降级到本地缓存搜索
const searchChain = new FallbackChain([
mcpSearchTool, // MCP server(优先)
cacheSearchTool, // 缓存搜索
staticFallbackTool, // 静态回退
])
typescript
用AI调试工具集成
> 我把一个MCP server接进了Agent,Agent没有调用它的工具。
> 请帮我排查:
> 1. 启动日志里有没有 "[mcp] xxx 工具发现失败,降级为不挂载" 的warn(连接阶段就挂了)
> 2. listTools 返回的工具名是否带了命名空间前缀(模型用的是前缀名)
> 3. 工具定义是否正确传递给模型(tools字段)
> 4. 检查模型是否支持function calling
> 5. 查看Agent日志,判断是否进入工具调用分支
text
小结
- 宿主化定位:给Claude Code做MCP server是使用者,让自己的产品连接外部MCP server是宿主
- 两个方向:对内接入(Client连外部server,stdio/HTTP两种transport)+ 对外暴露(MCPServer把自家工具映射出去);MCP区别于HTTP接口的核心是发现协议——工具清单自描述,模型自行决策调用
- 核心机制:
Client连接 →listTools()动态发现 → 包装注册,命名空间前缀(context7__xxx)防冲突 - 两种transport:stdio连本地子进程,Streamable HTTP连远程服务(SSE transport已废弃)
- ai-chat-ui的落地:
createMcpTools(60s超时预算、失败降级空工具集、schema透传)+loadContext7Tools(环境变量门控,key占位符坑)+ 只注入docs-agent(AI自己判断的挂载范围)+ 前端零改动 - 反向输出:
@mastra/mcp的MCPServer直接复用get_weather(零适配),stdio入口+.mcp.json接入Claude Code,MCP Inspector三transport验收 - 宿主的安全责任:超时限制、响应大小限制、API Key管理、重试与降级
↑